Repository navigation
feat(plugin-security, runtime, core)!: the permission-set grant, clone and activation doors take a package, and a grant is unique per package - #32306
Conversation
…set grant, clone and activation doors take a package; a grant is unique per package ADR-0150 D3/D5/D8, child of #31527, ahead of #31698 PR-2. - Grant (`sys_user_permission_set`): a set two packages hold, named with no package, is refused 409 RESOURCE_CONFLICT with the D3 sentence (core `PackageScopedNameRefusedError`); `permission_set_package` naming a holder is kept; naming a non-holder is refused at any holder count, never re-stamped (F3, declared narrowing). - Activation door `POST /security/_activation/permission/:name` and clone door `POST /security/permission-sets/:name/clone` take `?package=` (clone: the SOURCE's package); two holders and no package answer 409 with `details.candidates`; the activation door writes the named package's ledger row; query sets are closed (400); a clone target name two packages hold is taken (409). - core: the refused arm of `SecurityCatalogResolution` gains an additive `holders` member (structured candidates). - The grant's key is `(user_id, permission_set, permission_set_package, organization_id)`, `unique: 'global'`; package-less grants are kept to one per (user, set, organization) by a runtime guard `uniq_sys_user_permission_set_packageless` (metadata-protocol at kernel:ready for SQL, driver-mongodb runtime-owned unique). The guard is re-runnable, guesses no package and names the duplicated keys when it cannot build. The old three-column index retires only once the guard stands. driver-memory: declared limit, pinned. - One holder: every door answers as before. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…L-safe narrower key, never an orphan drop The full dogfood battery on the first head found three reds, each a consequence of this change: - An 18.0 database holds the grant's old key as `UNIQUE (user_id, permission_set, COALESCE(organization_id, '__global__'))` under the same generated name. The widening arm matched only a plain narrower, so the differ reported it as an orphan `drop_index` (destructive). It now matches the NULL-safe form too: kept until the guard stands on a certified table and then one safe replace; kept and never offered on a table that is not certified, where the COALESCE bucket still keeps NULL-organization rows to one. - A plan read before the sync that adds a gained column (`os migrate plan` over an upgraded database) left the narrower to the orphan arm. A gained column not yet physical now keeps it. - The stored-reference dogfood forged `permission_set_package` through the engine; F3 refuses that now. The CONTROL asserts the refusal and forges the value below the engine. The equivalence dogfood pins the grant index's retirement as one safe replace on the restored 18.0 store. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…ow; the doors test's engine double is ledgered system-context.mdx gains row 23h for stored-permission-set-package-stamp.ts#onUpdate, whose system-only read of permission_set_package judges a column-only bind (kept for a holder, 409 not-held-by-qualifier otherwise), and row 23c names the grant's 409 refusals; the site register is regenerated. engine-double-contract.pinned.json records the update double permission-set-package-doors.test.ts pins. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…g)`, the widening and three narrowings listed FROM → TO below it Seat 3's ruling on the declaration line: the door widening, F3 (the doors' ?package= naming a non-holder included), the closed query sets and the two-holder clone target each get one FROM → TO line under the arm. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
📓 Docs Drift CheckThis PR changes 4 package(s): 75 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 15 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 65 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ff397fbb87b796d1d6b6010f2db053694700205e && git checkout ff397fbb87b796d1d6b6010f2db053694700205e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06 && git checkout -B drift-repro c4b6160d21b7a4e65393a378c6cd7ddb9533bf13 && git merge --no-ff 6075ae7189c8ac7ae5ac03e40694f4a1b2dd8d06
node scripts/docs-audit/affected-docs.mjs --json c4b6160d21b7a4e65393a378c6cd7ddb9533bf13
|
|
domain:server#3 · os-support-ai · session_01WGufzeeBmtefMTT21doena Contract-review-claim: session_01WGufzeeBmtefMTT21doena · head 72f0a68 · 2026-11-10T17:38Z Face: narrowing, at |
|
domain:server#3 · os-support-ai · session_01WGufzeeBmtefMTT21doena Contract reviewServed-tier: Check-runs on this head (last poll): no failure. Pending: Build Core, Build & E2E UI, Test Core 1/8 to 8/8, Test UI 1/16 to 16/16, Type Check · workspace, Type Check UI. Green: Check Changeset, PR-body guards, all Lint jobs, Type Check source/consumer/debt gates, Governed Surface Queue Guard, Spec property liveness, doc links, PR size. Skipped by job condition, so they are no verdict here: the three Dogfood shards (PR events are excluded, which makes the green "Dogfood Regression Gate" vacuous on this head), "Temporal Conformance (live PG + MySQL)" (excluded on pull_request and merge_group, and it is the only job that runs the ① Derived judgments
② Semver level
FROM→TO completeness fails in three places:
③ Boundary flags
④ Design judgmentmust-fix
should-fix nit
Tried and held: two holders with and without a package at all three doors; one-holder CONTROLs; a package-less legacy row (Mongo missing field, SQL NULL); the migration run twice per dialect arm; an upgrade with duplicates (refused, named, no row touched, old key kept); a concurrent grant after the guard stands (the database refuses it); ADR-0112 pairs (409 Implemented-by: VERDICT: FAIL |
…carries (MySQL, MariaDB), never an orphan drop A #11627 shadow-carried narrower whose resolved key is exactly the superseded columns (any NULL-safe part the tenant column's) is accounted for by the widening arm and kept, with nothing offered: replace_unique_index drops the legacy name and retires no shadow. Before, it fell to the orphan arm as a destructive drop_index once the declaration was respelled (contract review must-fix 3 on #32306). Pinned at the differ, plain and NULL-safe, certified or not, with and without the guard, plus two CONTROLs. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…e-less guard is built at schema sync, an old key kept where it has no form, and a refused Mongo guard said at error ObjectQLPlugin triggers `objectql:schema-synced` when each schema-sync pass's DDL is done (OBJECTQL_SCHEMA_SYNCED_HOOK), and metadata-protocol builds `uniq_sys_user_permission_set_packageless` from it, so the guard stands before any later plugin's start() writes a grant, on a `bootPhaseHooks: false` host too. The kernel:ready build stays as a second chance and is a no-op once a pass settled it. On a server with no guard form (MySQL < 8.0.13, MariaDB < 10.3) the old three-column key is kept, or built under the driver's generated name where missing, so the widening arm keeps it until the guard exists. driver-mongodb reports a runtime-owned unique the server refuses (E11000, 85, 86) at error with what lists or names the conflict, and the sync goes on. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…older's own, and F3 at the suggestion stamp is declared and pinned Contract-review fix round on #32306 (seat 3 and epic rulings): - MF1: the permission activation store is addressed by (package, name), PERMISSION_LEDGER_ADDRESSING. Flipping one holder never touches the other's row, and both stay switchable. A row naming no package, or a package that no longer serves the name, is the one holder's switch (taken over, D8); with two holders, switching one on while such a row keeps it off is 409 naming that row, nothing written, and a write that would re-address another holder's switched-off row is 409 too. The false "every reader is name-keyed" rationale is corrected in the door's doc, authorization.mdx and the changeset. - MF2: F3 at sys_audience_binding_suggestion (R3) is declared in the changeset and system-context row 23h, with one pin. - SF4: a system insert naming a package for a name nothing holds is kept as written; declared and pinned. - SF7: judgeGrantSet and resolvePermissionSetAddress read the 'sys_metadata' sentinel as the environment, pinned. - The grant's permission_set_package description, its translations, the driver-memory limit in permission-sets.mdx, row 23c's ending, and the changeset trimmed to this PR's packages (the widening arm and the guard are the two PRs before it). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…ckage-less guard, six runtime indexes The roster gained uniq_sys_user_permission_set_packageless (#32164), so the booted run reports six runtime indexes, the sixth by its migration name. Test Core (8/8) on 72f0a68 caught the stale count. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…eps the logger's receiver (logger.error ?? logger.warn)(...) read the channel off the logger and called it detached, so a class logger (core's ObjectLogger) lost `this`. Called through the property now; check:logger-receiver-detach caught it. The P1b changeset gains the blank line before its Metadata-protocol line. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…index, not Array.prototype.at metadata-protocol's test layer compiles against a lib without Array.prototype.at, so its typecheck (CI Type Check · workspace on 58f31b6) refused ran.at(-1). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…rier under the superseded key's name, never an orphan drop A #11627 carrier whose generation expression could not be read sits under the narrower key's generated name, and the orphan arm does not skip an unresolved carrier: it offered it as a destructive drop_index, with and without the guard, certified or not, and beside the wider key's create_index when the gained column was absent. What it hashes is unknown, so the widening arm now keeps it and offers nothing (contract review must-fix 1). Pinned at the differ; the claim-loop comment and the changeset are corrected, and the changeset states how many first-party declarations this arm reads on main. The absent-gained-column term of the kept rule gets a pin of its own (should-fix 3): a plain narrower read before a REQUIRED gained column is physical is kept with only the wider key's create_index offered. Turning that term off had left every pin green; it now turns this one red. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…the table's DDL on every kernel, re-ensured after each pass The package-less grant guard was subscribed inside the runPlatformMigrations gate, so a kernel with an environmentId and nothing declared, and every one-shot CLI boot, created sys_user_permission_set at schema sync with no guard (contract review must-fix 1). The objectql:schema-synced subscription now sits outside the gate; the kernel:ready build stays a gated repair. A pass whose driver holds DDL back (os migrate plan, every dry-run mode, os migrate apply before its confirmation) builds nothing: the ensurer reads the driver's schemaDdlDeferred. The CLI's flush triggers the event again once it has performed what was held back, so os migrate apply builds the guard. A write mode that boots without deferring builds it at its sync. A built guard is no longer latched: every later pass ensures it again (IF NOT EXISTS), because a SQLite table rebuild drops it. Only a refusal is latched, so it is reported once per boot (should-fix 2). Wording that was true only once the grant key is widened now says so, and the Mongo options-conflict line no longer says the key is not enforced when the index in the way may be an older unique on the same fields. The stored-references fixture that rebuilds a database the release before D8 left also drops an index naming a package column in a partial WHERE: its template's write boot now builds the guard, and SQLite refuses to drop a column such an index still reads. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
…the three-column key, with the remedy by hand A MySQL or MariaDB store whose old grant key is carried by a hash shadow keeps it: the widening arm offers nothing for a shadow carrier, so that store keeps refusing one user a second package's set of one name in one organization. The changeset now says so and gives the ALTER that retires it once the package-less guard stands (contract review should-fix 2). The guard migration's module doc states the grant's declared key as this change makes it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WGufzgBi8iNgKbhq9r7VDT
|
domain:server#3 · os-support-ai · session_01WGufzgBi8iNgKbhq9r7VDT Hand-off: this round is pushed. Head What the three new commits carry
After #32356 and #32357 land, this PR's own diff is 1812 lines (+1686 / -126) in 40 files, measured over Measured on
|
| Step | Result |
|---|---|
build (turbo run build, docs excluded) |
119/119 tasks |
eslint over the changed .ts files |
exit 0 |
core |
152 files, 3399 tests |
runtime |
583 files; 11052 tests passed, 19 skipped |
plugin-security |
260 files; 6266 tests passed, 45 skipped |
metadata-protocol |
333 files passed, 7 skipped; 22685 tests passed, 35 skipped |
objectql |
585 files, 12180 tests |
service-automation |
272 files, 3649 tests |
driver-sql |
250 files passed, 13 skipped; 4224 tests passed, 227 skipped |
driver-mongodb |
37 files passed, 6 skipped; 753 tests passed, 201 skipped |
driver-memory |
76 files, 1776 tests |
NOT measured on this head (a container restart, then the stand-down)
- the
clisuite (on feat(metadata-protocol, objectql, driver-mongodb, cli): the permission-set grant table gets its package-less guard at schema sync on every kernel that runs its DDL, and a runtime-owned Mongo unique the server refuses is reported at error #32357's tree, which this branch carries, it passed whole: 629 files, 7831 tests); - the dogfood battery (it last passed whole on
ae6343780); - the md5 one-holder CONTROL (last measured on an earlier head: all identical to main's door source);
dispatch-gates --ran.
CI on this push is the next reading.
What remains
- A CR of the three commits. The doors themselves did not change this round.
- The PR body is out of date (a dev thread does not edit it):
- "built at
kernel:ready" is now "at schema sync, on every kernel that runs the table's DDL"; - the acceptance note "A hash-shadow-carried narrower … is still not matched" is superseded. fix(driver-sql): the widening arm keeps, or safely retires, an 18.0 NULL-safe narrower key, and keeps a shadow-carried one or one whose gained column is not yet physical, never an orphan drop #32356 keeps it and offers nothing, and the changeset declares the limit with its remedy (above);
- the Verification section predates this round.
- "built at
- Not merged with
main: the branch is 12 commits behindorigin/main326453e, andgit merge-treeis clean. - Landing order: fix(driver-sql): the widening arm keeps, or safely retires, an 18.0 NULL-safe narrower key, and keeps a shadow-carried one or one whose gained column is not yet physical, never an orphan drop #32356, then feat(metadata-protocol, objectql, driver-mongodb, cli): the permission-set grant table gets its package-less guard at schema sync on every kernel that runs its DDL, and a runtime-owned Mongo unique the server refuses is reported at error #32357, then this PR.
Generated by Claude Code
|
domain:server#3 · os-support-ai · session_01WGufzgBi8iNgKbhq9r7VDT {
"issue": 32164,
"status": "measured",
"held_by": "domain:server#6 (takeover 6788362280)",
"branch": "claude/project-thread-ekmc12",
"pr": 32306,
"head_measured": "4b62e66fc9",
"session": "session_01WGufzgBi8iNgKbhq9r7VDT",
"premise_still_valid": true,
"summary": "These are the four readings the hand-off (6788118653) left unmeasured on 4b62e66fc9: the whole cli suite, the dogfood battery, the md5 one-holder CONTROL and dispatch-gates --ran. All four are green locally. CI is red on one test, Test Core (7/8): the types callers ledger has 2 calls where the file now has 3. It reproduces locally, and #32357 carries the same defect (comment 6788459878). I did not fix it because the card is seat 6's. The head was measured as pushed, 4b62e66fc9 over base 9bff554ef, not merged with main.",
"tests": {
"cli": "@objectstack/cli, whole suite: 629 files passed, 2 skipped; 7842 tests passed, 10 skipped; exit 0",
"types_repo": "@objectstack/types `vitest run --project repo`: 1 failed, 10 passed (11). It fails on src/driver-error-classification.callers.test.ts › 'the non-test callers are exactly the enumerated ones, call sites included', the same failure as CI Test Core (7/8)",
"earlier_on_this_head": "These are in 6788118653: build 119/119, eslint exit 0, core, runtime, plugin-security, metadata-protocol, objectql, service-automation, driver-sql, driver-mongodb and driver-memory, all green",
"control": "md5 one-holder CONTROL (main e40f2df2's door and grant source as BASE, this head as HEAD): 16 hashed answers, 10 door and 6 grant, each identical to BASE. These are the 14 rows of the PR body's CONTROL table, where two grant rows hash twice. The run printed 'DIFFERS' first because the probe appended to the previous run's output files. The last run's 10 + 6 rows match the BASE files exactly (diff empty). Probe cleanup left 0 changed paths"
},
"gates": "node scripts/pm/dispatch-gates.mjs --commands on 4b62e66fc9 derived 132, and each ran with its exit code captured: 132 of 132 exit 0. --ran: '132 derived, 132 run, 0 NOT-MEASURED, 0 UNRUN', exit 0. dispatch-gates does not derive @objectstack/types test:repo, which is why the ledger failure below got past the local verify",
"dogfood_battery": "bash scripts/pm/os-verify-lock.sh -c 'pnpm --filter @objectstack/dogfood test', the whole suite: 449 files passed, 3 skipped (452); 3752 tests passed, 42 skipped (3794); step exit 0. The battery, cli, CONTROL and gates ran as one script under one lock hold: 'VERDICT command-exit 0 · held the lock 6749s', shared-box seconds. Each step's own exit code is recorded above",
"findings": [
{
"what": "CI Test Core (7/8) is red on 4b62e66fc9, and #32357 (0e9ff2186d) has the same content",
"evidence": "packages/types/src/driver-error-classification.callers.test.ts:445, the OPERATOR_TEXT_CALLERS row for packages/metadata-protocol/src/migrations/user-permission-set-packageless-index.ts, says calls: 2. The file has 3 operatorFacingErrorText( calls: line 360 (the conflict report), line 451 (the old-key failure) and line 434 (new). Line 434 is the catch in createUserPermissionSetGuardAtSchemaSync, logProblem(logger, '…skipped…', operatorFacingErrorText(err)), added by P1b round 2 (c51686cf5b here, 0e9ff2186d on #32357)",
"fix_options": [
"Set calls: 3 and add the pin the test asks for in user-permission-set-packageless-index.test.ts: make the schema-sync ensurer's seam throw an error carrying SENTINEL-21418-BOUND-VALUE, then assert the logged line does not contain it",
"Route that catch through one of the two existing call sites, so the count stays 2"
],
"owner": "domain:server#6, posted on #32357 as 6788459878. The fix belongs on #32357 first, then the same commit on this branch"
}
],
"pr_body_stale": "Unchanged from 6788118653. Seat 6 owns any update: 'built at `kernel:ready`' should now say at schema sync, on every kernel that runs the table's DDL. The hash-shadow acceptance note is superseded by #32356's fix and the changeset's declared limit. Verification predates this round; the readings above replace it (battery 449/3752, gates 132/132, cli 629/7842, CONTROL 16/16 identical)",
"mcp_calls": "read-only GitHub MCP only (pull_request_read, get_check_run, get_job_logs, actions_get, issue_read); the exact count was lost across two context compactions; no MCP writes",
"api_writes": {
"count": 9,
"endpoints": [
"git push claude/project-thread-vpgku3 → 132e3656dd (#32356), at the user's order",
"git push claude/project-thread-c1daza → 0e9ff2186d (#32357), at the user's order",
"git push claude/project-thread-ekmc12 → 4b62e66fc9 (#32306), at the user's order",
"POST issues/32356/comments 6788099539 (hand-off)",
"POST issues/32357/comments 6788106892 (hand-off)",
"POST issues/32306/comments 6788118653 (hand-off)",
"POST issues/32164/comments 6788125958 (hand-off)",
"POST issues/32357/comments 6788459878 (types-ledger finding for domain:server#6)",
"POST issues/32306/comments (this report)"
]
},
"open_questions": [],
"security_findings": "none",
"out_of_scope_findings": [
{
"class": "observation",
"what": "dispatch-gates --commands does not derive @objectstack/types test:repo for a diff that adds an operatorFacingErrorText( call outside packages/types, so the ledger miss reached CI",
"reach": "this card: 132/132 local gates green while CI Test Core (7/8) went red",
"carrier": "none"
}
]
}Generated by Claude Code |
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Epic red-line read at
|
…grant table's refusals; the environment owns its sentinel ledger row Delta review 6817263634 (should-fix A and nits) and epic read 6817314015 (S1) on #32306. - The one-grant check is its own pair of hooks at priority 50, after every refusal of the grant table's hooks (30, 35, 40, 45), so a second grant one of them refuses keeps that answer. It steps aside, as they do, on a holder a non-system write names that the caller cannot read: the engine's reference check answers it (#29029). Record validation still runs after every hook; that precedence is declared. - The check's reads and predicate live beside the backfill, which a dry run now asks, so it reports a would-be second grant as `duplicated`, not `bound`. - The refusal's cause names no index; the module doc no longer lists the same package among the pairs neither index sees. - Activation door: a row stamped with the overlay sentinel is the environment's own switch where the environment holds the name beside a package, re-addressed by id, never doubled; the package-less row's sentence says when it switches off every holder. - Changeset: the precedence move, the backfill's FROM against main, the environment-beside-a-package rows. Docs and system-context row 23h. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Epic red-line read at
|
…ecedence and the activation door's package-less sentence Round 4 of #32164 P2 (PR #32306), after the epic red-line read of 0859911 (6819720516) and the contract delta review r3. - [E2, red line 3] A second grant of one set to one user in one organization whose row record validation also refuses (`reason` past its maxLength) answers the one-grant check's 409 DUPLICATE_RECORD for system and non-system writers, where the store's key answered 400 VALIDATION_FAILED after validation; CONTROL: the same invalid row with no grant beside it is still 400. - [S3] Beside the environment's own ledger row, a switched-off row stamped with the overlay sentinel keeps the environment's set off: the 409 reads that row by the name alone, never "every holder". - The changeset says the check, at priority 50, also answers before a grant-table hook at the default priority (100), an app's own included. - Withheld; a security item is with the owner seat. Merges origin/main at 9b434a8. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Epic red-line delta read at
|
|
domain:server#2 · os-bill · session_01Utp6CaP1iy4RSEQbW6177G 维护者速读
席位意见:ACCEPT。契约复审 delta r4 PASS-with-nits(0 必修)在本 head;专题红线 delta 读 PASS 6820340332 在本 head。 席位核对 — ACCEPT · domain:server#2 · head
|
Requested by Bill · project thread
domain:server#2 · os-bill · session_01Utp6CaP1iy4RSEQbW6177G
维护者速读
permission_set_package/?package=指明“哪个包的那一个”;同名权限集由两个包持有时,不指明包就拒绝(409)并列出候选包。授权的唯一键加上了包这一列;不带包的授权仍是一人、一个权限集、一个组织只有一条,由上一段(P1b,已合入)建好的守卫保证;同一用户在同一组织里对同一权限集仍只能有一条授权(两个包各一条除外),由引擎里的写入缝把关,拒绝时与旧键的回答一致。权限集的开关台账按“包 + 名字”各占一行,开关一个包的不会碰另一个包的。!)。只有一个包持有时,三个入口的回答与 main 一致:17 项回答的 md5 在本轮代码上重测,与上一轮对照 main 入口源码时的结果逐字节相同,2 项灵敏度对照照样不同。唯一的例外已在 changeset 写明:环境自建且被覆盖层改过的权限集,开关入口写的台账行不再带sys_metadata标记而是空包,各读者对两者读法相同。收紧六处:指明一个并不持有该权限集的包会 409(以前静默改写或照存);建议记录指明建议包够不着的持有者会 409;两个入口只认package查询参数,其它参数回 400;克隆目标名若被两个包持有算已占用;同名多行台账里有不带包的行时开回会 409;AI 服务账号的权限天花板改按授权所指的那个包来判,判不出是哪个包时任一持有者越线即拒(后两处要到 objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2 之后才会出现)。第一方没有任何调用会碰到。旧的三列唯一索引只在守卫建成后作为一次 safe 替换退役(本地在 MariaDB 10.11 与 PostgreSQL 16 实测),不会被当作孤儿索引删掉。回滚即还原本 PR,无数据迁移。上一轮补齐了契约复审(6813617327)和专题红线复审(6813516860)的全部条目,两份复审在ec39bd8e5上都已通过(6817263634、6817314015)。本轮再补两处:一人一组的检查排到授权表其它拒绝之后,调用方读不到的持有者照旧答reference_not_found;环境与某个包同名时,带sys_metadata哨兵的台账行认作环境自己那一行。另有一处声明过的先后变化:第二条授权若同时也过不了记录校验,以前答 400,现在答 409,两种情况都什么也不存。packages/plugins/plugin-security/src/stored-permission-set-package-stamp.ts(F3 拒绝assertNamedPackageHolds,一人一组一权限集assertOneGrantPerSet),然后packages/runtime/src/domains/catalog-activation.ts(?package=定址与按包的台账行)和packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts(唯一键)。pnpm --filter @objectstack/runtime exec vitest run src/domains/permission-set-package-doors.test.ts,预期 38 条全过:两个包持有时不带包回 409 并带两个候选;?package=指 HR 回 200,只写 HR 自己的台账行,CRM 的行不动。check-governed-merges --branch判定 0/51)。按现行规则,契约复审和专题红线复审由席位来跑,通过后由席位落地。Fixes #32164
Clause-②: no (narrowing)
400 reference_not_found, the clone and activation doors'404) TO409 RESOURCE_CONFLICTnaming the holders; addressed with a package that holds it (permission_set_packageon the grant,?package=on clone and activation): FROM the package dropped TO exactly that package's set.(user_id, permission_set, organization_id)TO one per(user_id, permission_set, permission_set_package, organization_id), so one user may hold both packages' sets of one name in one organization. A grant naming no package stays one per(user_id, permission_set, organization_id)through the guard the preceding PR builds, except ondriver-memory(declared below).?package=dropped at the clone and activation doors (one holder: the holder's set) TO409 RESOURCE_CONFLICT, reasonnot-held-by-qualifier, naming the holders, with nothing stored or switched.409 RESOURCE_CONFLICT, reasonoutside-closure, naming that holder, nothing stored.400 VALIDATION_ERRORbefore any lookup, at the clone door and at the activation door for all six of its types (position,permission,sharing_rule,webhook,queue,assignment_rule), for anything butpackageonpermission, and for a repeated or blankpackage.409, as a name one package holds already was.409 RESOURCE_CONFLICTnaming that row, nothing written (ADR-0150 red line 1). The same with one holder when the name has more than one row, which no first-party writer makes. A disabled package still holds the name here, as the rule counts it.400 VALIDATION_FAILED(the store's key answered after validation) TO the one-grant check's409, nothing stored either way. Every refusal of the grant table's hooks, and the engine's answer for a holder the caller cannot read, keep their precedence.Metadata-protocol: none
Skills-docs: follow-up #32539 (skills/objectstack-data/rules/security.md: its grant paragraph still says one grant per user, set and organization is "enforced by the
unique: 'organization'index"; after this PR the key is(user_id, permission_set, permission_set_package, organization_id)with the package-less guard and the engine's one-grant check, and a grant may namepermission_set_package. This lane does not editskills/**.)BREAKING for a writer that sends
permission_set_packagenaming a package that does not hold the permission set (or, on a suggestion, a holder outside the suggesting package's reach), and for a request carrying a query parameter to the clone door or to the activation door, for any of its six types. No first-party writer or caller meets any of the narrowings: the boot backfill andos migrate stored-references bindwrite only a holder, and the Console sends no query to these doors. Every moved answer, door by door, is in the changeset.This is the last of three PRs for #32164. P1 (#32356,
48d6b477c7) taughtdriver-sql's widening arm the superseded grant key's 18.0, shadow-carried and not-yet-physical forms; P1b (#32357,e765a092) builds the grant's package-less guard at schema sync on every kernel that runs the table's DDL. Both are onmain, and this branch carriesmainmerged (371bd8d45), so the diff here is P2's own: 51 files, +2748 / -172 (2920 lines). Opened bydomain:server#3(os-support-ai); continued bydomain:server#2.What changes
plugin-security):grant-permission-set-name.tsjudges a non-system writer's grant through the security catalog by(package, name)(judgeGrantSet): a set two packages hold, named with no package, is refused with core'sPackageScopedNameRefusedError(RESOURCE_CONFLICT, 409, the D3 sentence,.refusal.candidates); a named holder is kept; a named non-holder is refused (not-held-by-qualifier).stored-permission-set-package-stamp.tskeeps a named package that holds the set for every writer and refuses one that does not (assertNamedPackageHolds), on insert, on update and on a column-only bind, for the grant and forsys_audience_binding_suggestion; it never re-stamps a named package. Before the boot seals the holders, a system insert's named package is kept unjudged (the backfill reads a stale one as none). Thesys_metadataoverlay provenance sentinel reads as no package, as core'scatalogEntryAddressreads it. On a suggestion (R3) a named holder must be in the suggesting package's reach (itself, its dependencies, the kernel packages), else409 outside-closure.plugin-security, the same seam,assertOneGrantPerSet): the two keys alone do not refuse every second grant of one set to one user in one organization that the old key refused, so the seam judges each grant insert, and each update that moves its user, set, organization or package, reading the other grants of that user, set and organization as the system in the writer's transaction. It admits a second grant only when the two name two different packages that each hold the set, and otherwise refuses with the engine's own duplicate-record envelope (DUPLICATE_RECORD, 409;409 UNIQUE_VIOLATION, "A record with this value already exists", on the wire), the answer the old key gave. The check is its own pair of hooks at priority 50, after every refusal of the grant table's hooks (the set's name 30, the agent ceiling set 35, the holder's membership 40, the AI service account's ceiling 45), so those keep their answer; and, as they do, it steps aside for a non-system write on a holder the write names that the caller's own read does not return, which the engine's reference check answers as a user that does not exist (objectql: a plainlookupwrite accepts a target the writer cannot read — the reference check probes it elevated, and onlymaster_detailasks the caller's read door #29029;holderLeftToReferenceCheck, now exported bygrant-holder-membership-refusal.ts). Record validation runs after every hook, so a second grant it would also refuse answers409wheremainanswered400(declared). Below the engine, a race, a batch and a write with no organization stay with the keys (declared in the changeset). The boot backfill (stored-permission-set-package-backfill.ts) catches a per-row unique violation, leaves that row with no package and reports it asduplicated, so the walk goes on; a dry run asks the check's own predicate and reports the same rows asduplicated, notbound.plugin-security,agent-account-grant-refusal.ts, ADR-0151 D3): the set an AI service account would hold is read as the authorization resolver reads it, a grant at its stored package and a position's sets from the position's package; a read the rule still refuses judges every holder of the name and fails closed.runtime,catalog-activation.ts):?package=PKGaddresses exactly PKG's set. The permission ledger store is addressed by(package, name)(addressing: 'package-and-name', scoped topermission): each holder keeps its own row, so switching one holder's set never touches the other's. Two holders and no package answer409withdetails.candidates; a non-holder is409 not-held-by-qualifier; switching a holder on while a package-less or stale row keeps it off is409naming that row, nothing written; a write that would re-address a row switched off for another holder is refused. With one holder, a package or the environment, the name's only row is the holder's and the write takes it over, as onmain. Holders are counted as the D3 rule counts them, a disabled package included. Where the environment holds the name beside a package, the name alone is the environment's set, so a row stamped with the overlay provenancesys_metadatais the environment's own switch and is re-addressed by id to name no package, never doubled (epic read 6817314015, S1); the refusal's sentence for a package-less row now says when it switches off every holder (the bare name refused). The query set is closed (package, forpermissiononly). Every other type keeps the name-addressed store.runtime,permission-set-clone.ts):?package=names the SOURCE's package (the body'spackagestill names where the copy lands); two holders and no package answer409withdetails.candidates; a target name two packages hold is taken; the query set is closed.security-catalog.ts): the refused arm ofSecurityCatalogResolutiongains an optionalholders, the candidates structured, so a door can answer them without re-deriving. Additive.objects/sys-user-permission-set.object.ts):(user_id, permission_set, organization_id),unique: 'organization'→(user_id, permission_set, permission_set_package, organization_id),unique: 'global'. The package-less guard P1b builds keeps grants that name no package to one per(user_id, permission_set, organization_id); the old three-column index retires only once the guard stands, throughdriver-sql's widening arm (P1) as onesafereplace. A store whose old key is carried by a hash shadow (MySQL, MariaDB) keeps it, declared in the changeset with the remedy by hand.metadata-protocol: the guard migration's module doc states the grant's declared key as this change makes it (a comment; no code).content/docs/permissions/permission-sets.mdx(the grant's package, the clone door's?package=, thedriver-memorylimit),authorization.mdx(the activation door's?package=and the per-package ledger rows),system-context.mdxrows 23c and 23h (the stamp's system column bind, the grant's 409 refusals and the one-grant check's system read; the generated site register regenerated). QA:docs/qa/platform-checklist/areas/access-security.json, itemaccess-security.security-catalog-activation-and-clone-doorsrevision 4 (the closed query and the two-holder arms).Why
ADR-0150 D3/D4/D5/D8, a child of #31527 ahead of #31698 PR-2. Once two packages can each hold a permission set of one name, a door that addresses a set by bare name must either be told which one or refuse naming both; a grant must record which one it grants, and the grant's key must let one user hold both. These doors answered a two-holder name as absent (404, or the grant's 400
reference_not_found) and dropped?package=, and a grant's named package was re-stamped rather than judged.Rulings applied: seat 3's F1 (
?package=on the POST doors; clone's names the source), F2 (an additiveholdersin core; structured candidates on/dataare #32250) and F3 (a named non-holder refused at any holder count, a column-only bind included); the epic seat's M5 conditions (comment 6770072902) and its two rulings after the first contract review (comment 6774878772): the permission ledger keyed by package with the refusal kept as a backstop, and condition 1 closed at schema sync (landed in P1b).Tests
plugin-security/src/grant-permission-set-package.test.ts(new, 19): a realObjectQLover SQLite with the grant-name hook and the stamp seam registered as the plugin registers them, over a stand-in registry holding one set in two packages. Two holders: no package is 409 with both candidates (envelope and/datawire), a holder is stored, a non-holder is 409; F3: one holder with another package named, an environment-only set with a package named, and a column-only bind; updates: an echo, a non-system package value, a rename; the overlay sentinel. CONTROLs: one holder, no package or the holder, stored stamped; a name nothing holds stays400 reference_not_found. [E2] The seam's refusal of a second grant equals the store's own unique violation, envelope and wire (measured against the four-column key with the seam's reads off), at the data door too; two holders' sets once each are stored.plugin-security/src/stored-permission-set-package-backfill.test.ts: F3 onsys_audience_binding_suggestion(insert and column-only update naming a non-holder: 409, nothing stored), and a system insert naming a package for an unheld name after the seal (kept as written). [E2] One grant of a set per user and organization at the seam: beside a package-less row or a stale one (one holder), the same package twice and a package-less grant beside a packaged one (two holders), a column-only bind and a move to another user are refused with nothing written; CONTROL: other users, organizations and sets, and an update that keeps the key. The backfill reports a row whose bind would duplicate asduplicated, leaves it null and binds the next row, with and without the seam. [SF3] A suggestion naming a holder outside the suggester's reach is409 outside-closureon insert and update; its own and a dependency's are kept.plugin-security/src/agent-account-grant-refusal.test.ts[E3]: two holders of one name, one above the ceiling: a grant naming the breaching package is refused and the other admitted, an update keeps the stored package, a grant whose read is refused fails closed (CONTROL: admitted when neither breaches), a position is judged on its own package's set.metadata-protocol's guard suites (SQLite, live PostgreSQL 16, live MySQL / MariaDB) anddriver-memory's pin assert that the store alone admits a packaged grant beside a package-less one, where the old key refused it, so a later guard turns them red; Mongo's real-server suite carries the same rows, pinned, not run.plugin-security/src/granted-by-writer-provenance.test.tsandgrant-permission-set-name.test.ts: pins that encoded the re-stamp now assert F3; the BASE answer is kept in comments. [E2] The without-guard CONTROL now asserts that the engine refuses a second package-less grant and that only a write below the engine lands one.runtime/src/domains/permission-set-package-doors.test.ts(new, 38): both doors throughHttpDispatcher.handleSecurityover the real catalog reader. Two holders: 409 with structured candidates;?package=a holder addresses exactly that set (activation writes that holder's own ledger row, clone copies it into its pair); each holder keeps its own row and both stay independently switchable; a package-less or stale row keeps both off and switching one on is 409; a non-holder is 409; closed query sets are 400; a two-holder target is taken. CONTROLs: one holder answers as before, a legacy package-less or stale row is the one holder's switch, a position row is re-attributed by name as before. [E1] The environment as the one holder, plain and overlay: a stale row and asys_metadatarow are taken over on enable and disable; with no row, a disable writes one naming no package. [SF2] A disabled second holder still counts: the bare name is 409 naming both, a package-less row keeps the served holder off (409), and?package=the disabled one names the served holder. [S1] A package and the environment hold one name, the environment's set plain and overlay: a sentinel row is the environment's switch on enable and on disable (re-addressed, never a second row); CONTROL:?package=the package writes its own row and leaves the sentinel row alone.grant-holder-membership-refusal.test.ts, a holder the caller cannot read that already holds the set gets one answer in every store, the reference refusal atuser_id, on insert and on an update that moves a grant onto it (CONTROL: a holder the caller reads gets the409);agent-account-grant-refusal.test.ts, a second grant of a breaching set to an AI service account is the ceiling's400on insert and on a move (CONTROL: within the ceiling, the check's409);stored-permission-set-package-backfill.test.ts, a dry run reports the would-be second grant asduplicated.core/src/security/security-catalog.test.ts:holderson a refusal, in candidate order.stored-reference-package-column.dogfood.test.ts's forged-column CONTROL asserts the engine now refuses the forge (F3) and writes it below the engine;upgrade-18x-index-equivalence.dogfood.test.tspins the grant index's retirement on the restored 18.0 store as one safe replace.Acceptance notes
main's door source (the environment's overlay row is the declared exception)./dataare rest + types: the /data door's 409 RESOURCE_CONFLICT for a thrown package-scoped refusal carries details.candidates (ADR-0150 D5; sibling of #32201) #32250: the grant's refusal there carries the D3 sentence naming every holder, and the thrown error carries.refusal.candidates.memory-user-permission-set-packageless.test.tsasserts the driver's own admission, so a future guard there turns it red on purpose.autoMigrate: 'safe', oros migrate apply) replaces it, after the guard stands. On an 18.0 database that index is in the NULL-safe COALESCE form: retired only on a certified table and kept everywhere else (P1). A MySQL or MariaDB store whose old key is carried by a hash shadow keeps the three-column key; the changeset gives theALTER TABLEthat retires it by hand once the guard stands (P1 keeps the carrier and offers nothing, never an orphan drop).maingives for one holder, and the clamp at evaluation still holds. Carried by plugin-security: the AI-account ceiling refusal judges served holders only, so a disabled package's breaching set can be granted to an AI account #32600 (blocked by this PR).Verification
Measured on
085991120(this round's commit over the merge ofmainat371bd8d45,main3452dec21), on a shared container. Every suite ran underscripts/pm/os-verify-lock.sh, which excludes other locked runs only, so no wall-clock figure here is an idle-box reading. The workspace was rebuilt after the merge (turbo run build, 119 tasks).Suites, types, lint
pnpm --filter <pkg> exec vitest runon085991120, exit 0 each:@objectstack/plugin-security: 262 files; 6460 passed, 45 skipped.@objectstack/runtime: 586 files; 11283 passed, 19 skipped.@objectstack/plugin-auth: 264 files; 4874 passed, 10 skipped (the first-party grant writers).@objectstack/core: 153 files, 3429 passed.@objectstack/dogfood: 451 passed, 3 skipped files; 3781 passed, 42 skipped (the dogfood battery the!title owes).@objectstack/metadata-protocol(ec39bd8e5's tree: 22719 passed, 36 skipped),@objectstack/driver-memory(1785 passed),@objectstack/driver-mongodb(753 passed, 201 skipped: the real-server suites skip, no mongod),@objectstack/cli(7868 passed, 10 skipped).pnpm --filter <pkg> run typecheckforplugin-security,runtimeanddogfood: exit 0 each.pnpm lint(eslint over the whole repository): exit 0.Ablations. Each fix was reverted alone and its pins went red, then restored. This round: the one-grant check's stand-down on a holder the caller cannot read (2 failed); its priority after the agent-account refusal (1); the dry run's prediction (1); the environment's sentinel row as its own (4), and its re-address by id (4). Earlier rounds, unchanged code: E1, the sole-holder take-over (6); should-fix 2, the rule's holder count (1); E2, the one-grant check (4), the backfill's per-row catch (2) and the check's own-id exclusion (1); E3, the grant's package read (3) and the fail-closed read (1); should-fix 3, the closure restriction (1).
Gates.
node scripts/pm/dispatch-gates.mjs --commandsthen--ranon085991120: 116 derived, 116 run, 0 NOT-MEASURED, every one exit 0. Also run, exit 0 each:check-published-list-mirrors,check:changeset-fixed,check:error-code-casing,check:filter-alias-parity,check:published-readme-exports,check:route-ledger-census,check-system-context-census,check:platform-checklist,check-commit-card-trailers --range origin/main..HEADandcheck-governed-merges --branch(0 of 51 paths governed; 2920 changed lines, under 3000).md5 one-holder CONTROL, re-measured on
085991120. The same scratch probes as the earlier rounds: all 19 rows hash exactly as onec39bd8e5andaa4c4c413, whose 17 one-holder rows matchedmain's door source byte for byte, and the 2 sensitivity rows still differ. Not in that table, and declared in the changeset: the row the activation door writes for an environment-authored overlay set names no package wheremainwrotesys_metadata; every reader reads both as no package.main's door source)?package=(must differ)45a861dd(388 B)46eaaf85(424 B)4097dabb(766 B)606bf40b(606 B)Live servers (local, this container).
@objectstack/metadata-protocol's guard suites (user-permission-set-packageless-index.live-mysqland.live-postgres) with the store-alone rows, onec39bd8e5(unchanged since): 2 files, 7 tests passed.main's key on both servers (2 of 2 each), and the 8 live files ofmetadata-protocol(45 tests).main's CI (run 43132792602); this PR's key and guard together first run there onmain's push (Temporal Conformance is skipped on a pull request).fastdl.mongodb.org, there is no docker daemon, and this session's permission policy refused the other routes to a binary. No CI job runs driver-mongodb's real-server suites either (OS_TEST_MONGODB_MEMORY_SERVER_ENABLED=1is set nowhere). The changeset says "pinned, not run" for Mongo accordingly.Observation, not this PR's.
@objectstack/driver-sql's live suite against MariaDB 10.11 (OS_TEST_MYSQL_URL), first round: 44 failed, 6297 passed, 4 skipped tests; 18 files failed, all inlive mysql/live MySQLdescribes (JSON column type, hash-shadow and NOW-precision readings among them).driver-sqlis byte-identical tomainon this branch, and no CI lane runs MariaDB; CI's MySQL lane is 8.0. No card.CI. On
ec39bd8e5: 72 check runs, 60 success, 12 skipped, 0 failed (read by the delta review, 6817263634; the body edit re-ran four jobs). The skipped ones are skipped by their own condition on a pull request: the Dogfood shard matrix, Temporal Conformance, the 18.x upgrade harness, Dogfood Verify CLI, the packed-tarball smoke, the two queue verdicts, the schedule gate, Lint UI, UI Published Artifacts, and a re-run each of Auto Label and Check PR Size. On085991120: 68 check runs, 58 success, 10 skipped, 0 failed, read before this body edit; the skipped ones are the same jobs as onec39bd8e5without the two re-runs.Follow-ups
Filed by the seat; none is this PR's to fix.
permission_set_packageon a grant and?package=on the clone and activation doors, so an administrator can act on a two-holder name from the Console once objectql: narrow the phase-1 one-holder refusal from environment to namespace for permission set (ADR-0150 child B, permission set family) #31698 PR-2 lands.skills/objectstack-data/rules/security.mdstill states the old grant key (theSkills-docs:line).driver-sqllive suite's 44 failures on MariaDB 10.11 stay an observation (Verification); no card.Human Minimum
packages/specholds no runtime logic. (No spec change.)pnpm lintpasses. (pnpm lint: exit 0.)main, one pull request. (Three PRs for one card by the seat's split ruling; this is the last.)packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.## 维护者速读is the maintainer's Chinese summary the dispatch rules ask for.)🤖 Generated with Claude Code
https://claude.ai/code/session_01Utp6CZ9zs2B33JD9Z2RiDE